昨天我們學會了參數化測試,用優雅的方式處理大量測試資料。今天要解決一個新挑戰:「如何測試依賴外部服務的程式碼?」
想像你有個寄送通知的功能,它會真的寄出 email。測試時,你不希望真的寄信出去。這時候就需要「測試替身」來幫忙了!
今天結束後,你將學會:
Mockery
用法第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期
├── Day 06 - 參數化測試
├── Day 07 - 測試替身基礎 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)
測試替身(Test Double)就像電影中的替身演員,在測試時代替真實的依賴物件。
Stub(存根) - 提供固定回應
Mock(模擬) - 驗證互動行為
Spy(間諜) - 監控真實行為
先建立一個簡單的 EmailService 和 NotificationService。
建立 app/Services/EmailService.php
:
<?php
namespace App\Services;
class EmailService
{
public function send(string $to, string $subject, string $body): bool
{
// 實際實作會真的寄信
echo "Sending email to {$to}\n";
return true;
}
}
建立 app/Services/NotificationService.php
:
<?php
namespace App\Services;
class NotificationService
{
public function __construct(
private EmailService $emailService
) {}
public function notify(string $userEmail, string $message): bool
{
return $this->emailService->send(
$userEmail,
'Notification',
$message
);
}
}
建立 tests/Unit/Day07/NotificationServiceTest.php
:
<?php
use App\Services\EmailService;
use App\Services\NotificationService;
describe('NotificationService with Mock', function() {
it('sends email when notifying user', function() {
// 建立 Mock
$mockEmailService = Mockery::mock(EmailService::class);
$mockEmailService->shouldReceive('send')
->once()
->with('user@example.com', 'Notification', 'Hello!')
->andReturn(true);
$notificationService = new NotificationService($mockEmailService);
// 執行測試
$result = $notificationService->notify('user@example.com', 'Hello!');
// 驗證結果
expect($result)->toBe(true);
});
});
建立 app/Services/RandomGenerator.php
:
<?php
namespace App\Services;
class RandomGenerator
{
public function generate(int $min, int $max): int
{
return rand($min, $max);
}
}
建立 app/Services/GameService.php
:
<?php
namespace App\Services;
class GameService
{
public function __construct(
private RandomGenerator $randomGenerator
) {}
public function rollDice(): int
{
return $this->randomGenerator->generate(1, 6);
}
public function isWinning(int $diceValue): bool
{
return $diceValue >= 4;
}
}
建立 tests/Unit/Day07/GameServiceTest.php
:
<?php
use App\Services\RandomGenerator;
use App\Services\GameService;
describe('GameService with Stub', function() {
it('wins when dice value is 4 or higher', function() {
// 建立 Stub - 固定回傳 5
$stubRandomGenerator = Mockery::mock(RandomGenerator::class);
$stubRandomGenerator->shouldReceive('generate')
->with(1, 6)
->andReturn(5);
$gameService = new GameService($stubRandomGenerator);
$diceValue = $gameService->rollDice();
$isWin = $gameService->isWinning($diceValue);
expect($diceValue)->toBe(5);
expect($isWin)->toBe(true);
});
it('loses when dice value is less than 4', function() {
// 建立 Stub - 固定回傳 2
$stubRandomGenerator = Mockery::mock(RandomGenerator::class);
$stubRandomGenerator->shouldReceive('generate')
->with(1, 6)
->andReturn(2);
$gameService = new GameService($stubRandomGenerator);
$diceValue = $gameService->rollDice();
$isWin = $gameService->isWinning($diceValue);
expect($diceValue)->toBe(2);
expect($isWin)->toBe(false);
});
});
建立 app/Services/Logger.php
:
<?php
namespace App\Services;
class Logger
{
public function log(string $message): void
{
echo "[LOG] {$message}\n";
}
}
建立 app/Services/Calculator.php
:
<?php
namespace App\Services;
class Calculator
{
public function __construct(
private Logger $logger
) {}
public function add(int $a, int $b): int
{
$result = $a + $b;
$this->logger->log("Adding {$a} + {$b} = {$result}");
return $result;
}
public function subtract(int $a, int $b): int
{
$result = $a - $b;
$this->logger->log("Subtracting {$a} - {$b} = {$result}");
return $result;
}
}
建立 tests/Unit/Day07/CalculatorWithSpyTest.php
:
<?php
use App\Services\Calculator;
use App\Services\Logger;
describe('Calculator with Spy', function() {
it('logs calculation when adding', function() {
// 使用 Spy 監控 log 方法
$logger = Mockery::spy(Logger::class);
$calculator = new Calculator($logger);
$result = $calculator->add(2, 3);
// 驗證計算結果
expect($result)->toBe(5);
// 驗證 log 被呼叫
$logger->shouldHaveReceived('log')
->once()
->with('Adding 2 + 3 = 5');
});
it('logs calculation when subtracting', function() {
$logger = Mockery::spy(Logger::class);
$calculator = new Calculator($logger);
$result = $calculator->subtract(5, 3);
expect($result)->toBe(2);
$logger->shouldHaveReceived('log')
->once()
->with('Subtracting 5 - 3 = 2');
});
});
// ✅ 好的做法:清楚的測試意圖
it('sends notification email', function() {
$mockEmail = Mockery::mock(EmailService::class);
$mockEmail->shouldReceive('send')->andReturn(true);
// ... 簡單明瞭的測試
});
// ❌ 避免:過度複雜的設置
it('does everything', function() {
// 10 行的 mock 設置...
});
// ✅ 好的做法:專注單一行為
it('calls email service with correct parameters', function() {
// 只測試參數傳遞
});
it('returns true when email is sent successfully', function() {
// 只測試回傳值
});
// ✅ 好的做法:驗證重要的互動
$mock->shouldReceive('send')
->once()
->with($expectedParams);
// ❌ 避免:過度驗證
$mock->shouldReceive('method1')->times(1);
$mock->shouldReceive('method2')->times(2);
$mock->shouldReceive('method3')->times(3);
// ... 太多不必要的驗證
今天我們學會了:
✅ 測試替身的三種類型
✅ Mockery 測試工具
Mockery::mock()
:建立 Mock 物件Mockery::spy()
:建立 Spy 物件shouldReceive()
:設定預期行為shouldHaveReceived()
:驗證已發生行為✅ 實務應用
試著為以下 PaymentService
寫測試:
class PaymentService
{
public function __construct(
private PaymentGateway $gateway,
private Logger $logger
) {}
public function processPayment(float $amount): bool
{
$this->logger->log("Processing payment: \${$amount}");
if ($amount <= 0) {
$this->logger->log('Invalid amount');
return false;
}
$result = $this->gateway->charge($amount);
$this->logger->log("Payment result: " . ($result ? 'success' : 'failed'));
return $result;
}
}
提示:
PaymentGateway
的 charge
方法Logger
的 log
方法明天我們將學習「例外處理測試」,了解如何測試錯誤情況! 🚀